use diffy;
use std::env;
use std::fmt::{Debug, Display};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::str::Utf8Error;
use tracing::info;
use walkdir::WalkDir;

#[derive(Debug)]
pub enum CheckDiffError {
    /// Git related errors
    FailedGit(GitError),
    /// Error for generic commands
    FailedCommand(&'static str),
    /// UTF8 related errors
    FailedUtf8(Utf8Error),
    /// Error for building rustfmt from source
    FailedSourceBuild(&'static str),
    /// Error when obtaining binary version
    FailedBinaryVersioning(PathBuf),
    /// Error when obtaining cargo version
    FailedCargoVersion(&'static str),
    IO(std::io::Error),
}

impl From<io::Error> for CheckDiffError {
    fn from(error: io::Error) -> Self {
        CheckDiffError::IO(error)
    }
}

impl From<GitError> for CheckDiffError {
    fn from(error: GitError) -> Self {
        CheckDiffError::FailedGit(error)
    }
}

impl From<Utf8Error> for CheckDiffError {
    fn from(error: Utf8Error) -> Self {
        CheckDiffError::FailedUtf8(error)
    }
}

#[derive(Debug)]
pub enum GitError {
    FailedClone { stdout: Vec<u8>, stderr: Vec<u8> },
    FailedRemoteAdd { stdout: Vec<u8>, stderr: Vec<u8> },
    FailedFetch { stdout: Vec<u8>, stderr: Vec<u8> },
    FailedSwitch { stdout: Vec<u8>, stderr: Vec<u8> },
    IO(std::io::Error),
}

impl From<io::Error> for GitError {
    fn from(error: io::Error) -> Self {
        GitError::IO(error)
    }
}

pub struct Diff {
    src_format: String,
    feature_format: String,
}

impl Display for Diff {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let patch = diffy::create_patch(self.src_format.as_str(), self.feature_format.as_str());
        write!(f, "{}", patch)
    }
}

impl Diff {
    pub fn is_empty(&self) -> bool {
        let patch = diffy::create_patch(self.src_format.as_str(), self.feature_format.as_str());
        patch.hunks().is_empty()
    }
}

pub struct CheckDiffRunners<F, S> {
    feature_runner: F,
    src_runner: S,
}

pub trait CodeFormatter {
    fn format_code<'a>(
        &self,
        code: &'a str,
        config: &Option<Vec<String>>,
    ) -> Result<String, CheckDiffError>;
}

pub struct RustfmtRunner {
    ld_library_path: String,
    binary_path: PathBuf,
}

impl<F, S> CheckDiffRunners<F, S> {
    pub fn new(feature_runner: F, src_runner: S) -> Self {
        Self {
            feature_runner,
            src_runner,
        }
    }
}

impl<F, S> CheckDiffRunners<F, S>
where
    F: CodeFormatter,
    S: CodeFormatter,
{
    /// Creates a diff generated by running the source and feature binaries on the same file path
    pub fn create_diff(
        &self,
        path: &Path,
        additional_configs: &Option<Vec<String>>,
    ) -> Result<Diff, CheckDiffError> {
        let code = std::fs::read_to_string(path)?;
        let src_format = self.src_runner.format_code(&code, additional_configs)?;
        let feature_format = self.feature_runner.format_code(&code, additional_configs)?;
        Ok(Diff {
            src_format,
            feature_format,
        })
    }
}

impl RustfmtRunner {
    fn get_binary_version(&self) -> Result<String, CheckDiffError> {
        let Ok(command) = Command::new(&self.binary_path)
            .env("LD_LIBRARY_PATH", &self.ld_library_path)
            .args(["--version"])
            .output()
        else {
            return Err(CheckDiffError::FailedBinaryVersioning(
                self.binary_path.clone(),
            ));
        };

        let binary_version = std::str::from_utf8(&command.stdout)?.trim();
        return Ok(binary_version.to_string());
    }
}

impl CodeFormatter for RustfmtRunner {
    //  Run rusfmt to see if a diff is produced. Runs on the code specified
    //
    // Parameters:
    // code: Code to run the binary on
    // config: Any additional configuration options to pass to rustfmt
    //
    fn format_code<'a>(
        &self,
        code: &'a str,
        config: &Option<Vec<String>>,
    ) -> Result<String, CheckDiffError> {
        let config = create_config_arg(config);
        let mut command = Command::new(&self.binary_path)
            .env("LD_LIBRARY_PATH", &self.ld_library_path)
            .args([
                "--unstable-features",
                "--skip-children",
                "--emit=stdout",
                config.as_str(),
            ])
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()?;

        command.stdin.as_mut().unwrap().write_all(code.as_bytes())?;
        let output = command.wait_with_output()?;
        Ok(std::str::from_utf8(&output.stdout)?.to_string())
    }
}

/// Creates a configuration in the following form:
/// <config_name>=<config_val>, <config_name>=<config_val>, ...
fn create_config_arg(config: &Option<Vec<String>>) -> String {
    let config_arg: String = match config {
        Some(configs) => {
            let mut result = String::new();
            for arg in configs.iter() {
                result.push(',');
                result.push_str(arg.as_str());
            }
            result
        }
        None => String::new(),
    };
    let config = format!(
        "--config=error_on_line_overflow=false,error_on_unformatted=false{}",
        config_arg.as_str()
    );
    config
}
/// Clone a git repository
///
/// Parameters:
/// url: git clone url
/// dest: directory where the repo should be cloned
pub fn clone_git_repo(url: &str, dest: &Path) -> Result<(), GitError> {
    let git_cmd = Command::new("git")
        .env("GIT_TERMINAL_PROMPT", "0")
        .args([
            "clone",
            "--quiet",
            url,
            "--depth",
            "1",
            dest.to_str().unwrap(),
        ])
        .output()?;

    // if the git command does not return successfully,
    // any command on the repo will fail. So fail fast.
    if !git_cmd.status.success() {
        let error = GitError::FailedClone {
            stdout: git_cmd.stdout,
            stderr: git_cmd.stderr,
        };
        return Err(error);
    }

    info!("Successfully clone repository.");
    return Ok(());
}

pub fn git_remote_add(url: &str) -> Result<(), GitError> {
    let git_cmd = Command::new("git")
        .args(["remote", "add", "feature", url])
        .output()?;

    // if the git command does not return successfully,
    // any command on the repo will fail. So fail fast.
    if !git_cmd.status.success() {
        let error = GitError::FailedRemoteAdd {
            stdout: git_cmd.stdout,
            stderr: git_cmd.stderr,
        };
        return Err(error);
    }

    info!("Successfully added remote: {url}");
    return Ok(());
}

pub fn git_fetch(branch_name: &str) -> Result<(), GitError> {
    let git_cmd = Command::new("git")
        .args(["fetch", "feature", branch_name])
        .output()?;

    // if the git command does not return successfully,
    // any command on the repo will fail. So fail fast.
    if !git_cmd.status.success() {
        let error = GitError::FailedFetch {
            stdout: git_cmd.stdout,
            stderr: git_cmd.stderr,
        };
        return Err(error);
    }

    info!("Successfully fetched: {branch_name}");
    return Ok(());
}

pub fn git_switch(git_ref: &str, should_detach: bool) -> Result<(), GitError> {
    let detach_arg = if should_detach { "--detach" } else { "" };
    let args = ["switch", git_ref, detach_arg];
    let output = Command::new("git")
        .args(args.iter().filter(|arg| !arg.is_empty()))
        .output()?;
    if !output.status.success() {
        tracing::error!("Git switch failed: {output:?}");
        let error = GitError::FailedSwitch {
            stdout: output.stdout,
            stderr: output.stderr,
        };
        return Err(error);
    }
    info!("Successfully switched to {git_ref}");
    return Ok(());
}

pub fn change_directory_to_path(dest: &Path) -> io::Result<()> {
    let dest_path = Path::new(&dest);
    env::set_current_dir(&dest_path)?;
    info!(
        "Current directory: {}",
        env::current_dir().unwrap().display()
    );
    return Ok(());
}

pub fn get_ld_library_path(dir: &Path) -> Result<String, CheckDiffError> {
    let Ok(command) = Command::new("rustc")
        .current_dir(dir)
        .args(["--print", "sysroot"])
        .output()
    else {
        return Err(CheckDiffError::FailedCommand("Error getting sysroot"));
    };
    let sysroot = std::str::from_utf8(&command.stdout)?.trim_end();
    let ld_lib_path = format!("{}/lib", sysroot);
    return Ok(ld_lib_path);
}

pub fn get_cargo_version() -> Result<String, CheckDiffError> {
    let Ok(command) = Command::new("cargo").args(["--version"]).output() else {
        return Err(CheckDiffError::FailedCargoVersion(
            "Failed to obtain cargo version",
        ));
    };

    let cargo_version = std::str::from_utf8(&command.stdout)?.trim_end();
    return Ok(cargo_version.to_string());
}

/// Obtains the ld_lib path and then builds rustfmt from source
/// If that operation succeeds, the source is then copied to the output path specified
pub fn build_rustfmt_from_src(
    binary_path: PathBuf,
    dir: &Path,
) -> Result<RustfmtRunner, CheckDiffError> {
    //Because we're building standalone binaries we need to set `LD_LIBRARY_PATH` so each
    // binary can find it's runtime dependencies.
    // See https://github.com/rust-lang/rustfmt/issues/5675
    // This will prepend the `LD_LIBRARY_PATH` for the master rustfmt binary
    let ld_lib_path = get_ld_library_path(&dir)?;

    info!("Building rustfmt from source");
    let Ok(_) = Command::new("cargo")
        .current_dir(dir)
        .args(["build", "-q", "--release", "--bin", "rustfmt"])
        .output()
    else {
        return Err(CheckDiffError::FailedSourceBuild(
            "Error building rustfmt from source",
        ));
    };

    std::fs::copy(dir.join("target/release/rustfmt"), &binary_path)?;

    return Ok(RustfmtRunner {
        ld_library_path: ld_lib_path,
        binary_path,
    });
}

// Compiles and produces two rustfmt binaries.
// One for the current master, and another for the feature branch
// Parameters:
// dest: Directory where rustfmt will be cloned
pub fn compile_rustfmt(
    dest: &Path,
    remote_repo_url: String,
    feature_branch: String,
    commit_hash: Option<String>,
) -> Result<CheckDiffRunners<RustfmtRunner, RustfmtRunner>, CheckDiffError> {
    const RUSTFMT_REPO: &str = "https://github.com/rust-lang/rustfmt.git";

    clone_git_repo(RUSTFMT_REPO, dest)?;
    change_directory_to_path(dest)?;
    git_remote_add(remote_repo_url.as_str())?;
    git_fetch(feature_branch.as_str())?;

    let cargo_version = get_cargo_version()?;
    info!("Compiling with {}", cargo_version);
    let src_runner = build_rustfmt_from_src(dest.join("src_rustfmt"), dest)?;
    let should_detach = commit_hash.is_some();
    git_switch(
        commit_hash.unwrap_or(feature_branch).as_str(),
        should_detach,
    )?;

    let feature_runner = build_rustfmt_from_src(dest.join("feature_rustfmt"), dest)?;
    info!("RUSFMT_BIN {}", src_runner.get_binary_version()?);
    info!(
        "Runtime dependencies for (src) rustfmt -- LD_LIBRARY_PATH: {}",
        src_runner.ld_library_path
    );
    info!("FEATURE_BIN {}", feature_runner.get_binary_version()?);
    info!(
        "Runtime dependencies for (feature) rustfmt -- LD_LIBRARY_PATH: {}",
        feature_runner.ld_library_path
    );

    return Ok(CheckDiffRunners {
        src_runner,
        feature_runner,
    });
}

/// Searches for rust files in the particular path and returns an iterator to them.
pub fn search_for_rs_files(repo: &Path) -> impl Iterator<Item = PathBuf> {
    return WalkDir::new(repo).into_iter().filter_map(|e| match e.ok() {
        Some(entry) => {
            let path = entry.path();
            if path.is_file() && path.extension().map_or(false, |ext| ext == "rs") {
                return Some(entry.into_path());
            }
            return None;
        }
        None => None,
    });
}

/// Calculates the number of errors when running the compiled binary and the feature binary on the
/// repo specified with the specific configs.
pub fn check_diff(
    config: Option<Vec<String>>,
    runners: CheckDiffRunners<impl CodeFormatter, impl CodeFormatter>,
    repo: &Path,
) -> i32 {
    let mut errors = 0;
    let iter = search_for_rs_files(repo);
    for file in iter {
        match runners.create_diff(file.as_path(), &config) {
            Ok(diff) => {
                if !diff.is_empty() {
                    eprint!("{diff}");
                    errors += 1;
                }
            }
            Err(e) => {
                eprintln!(
                    "Error creating diff for {:?}: {:?}",
                    file.as_path().display(),
                    e
                );
                errors += 1;
            }
        }
    }

    return errors;
}
